It's time to see how you can put all the capabilities you've seen so far to good use. I'll continue to develop the original CPerson class as an example and expand it as I introduce new concepts.
If you look at how Visual Basic defines its own objects (forms, controls, and so on), you'll notice that not all properties can be both read from and written to. For example, you can't modify the MultiSelect property of a ListBox control at run time, and you can't modify the height of a ComboBox control even at design time. Depending on the nature of your class, you might have many good reasons to limit the access to your properties, making them read-only or (less frequently) write-only.
Say that you want to add an Age property to your CPerson class. It depends on the BirthDate property, so it should be a read-only property. In Visual Basic, you can make a property read-only by simply omitting its Property Let procedure:
Property Get Age() As Integer Age = Year(Now) - Year(BirthDate) End Property |
To prove that you now have a read-only property, try to execute this code:
pers.Age = 50 |
The Visual Basic compiler traps this logical error as soon as you try to run your program and won't compile the program until you correct or delete the statement.
Occasionally, you might even want to create write-only properties. A typical example is a Password property exposed by an imaginary LoginDialog object. The property can be assigned to validate the login process but shouldn't be readable so as not to compromise the security of the application itself. In Visual Basic, such a write-only property can be easily implemented by writing a Property Let procedure while omitting the corresponding Property Get routine:
Private m_Password As String Property Let Password(ByVal newValue As String) ' Validate the password, raise error if not valid. ' ... ' If all is OK, assign to Private member variable. m_Password = newValue End Property |
To be honest, I don't often find anything very useful for this particular Visual Basic feature to do, so I have reported it mostly for the sake of completeness. Write-only properties are often confusing and are perceived as unnatural by most developers. If need dictates a write-only property, I prefer to create a method that accepts the value as an argument (SetPassword, in this particular example).
Write-once/read-many properties are a bit more interesting and useful than pure write-only properties. For example, the LoginDialog object described in the previous paragraph might expose a UserName property of this type. Once a user logs in, your code assigns his or her name to this property; the rest of the application can then read it but can't modify it. Here's another example: in an Invoice class, the Number property might be rendered as a write-once/read-many property because once you assign a number to an invoice, arbitrarily changing it might cause serious problems in your accounting system.
Visual Basic doesn't offer a native system to implement such write-once/read-many properties, but it's easy to do that with some additional lines of code. Let's say that you want to provide our CPerson class with an ID property that can be assigned only once but read as many times as you need. Here's a possible solution, based on a Static local variable:
Private m_ID As Long Public Property Get ID() As Long ID = m_ID End Property Public Property Let ID(ByVal newValue As Long) Static InitDone As Boolean If InitDone Then Err.Raise 1002, , "Write-once property" InitDone = True m_ID = newValue End Property |
Here's an alternative solution, which spares you the additional Static variable but consumes some additional bytes in memory (16 bytes instead of 6):
Private m_ID As Variant Public Property Get ID() As Long ID = m_ID End Property Public Property Let ID(ByVal newValue As Long) If Not IsEmpty(m_ID) Then Err.Raise 1002, , "Write-once property" m_ID = newValue End Property |
In both cases, the interface that the class exposes to the outside is the same. (ID is a Long property.) This is another example of how a good encapsulation scheme lets you vary the internal implementation of a class without affecting the code that uses it.
From the point of view of the client code (that is, the code that actually uses your class), a read-only property is similar to a function. In fact, in all cases a read-only property can be invoked only in expressions and can never appear to the left of an assignment symbol. So this raises a sort of semantic problem: When is it preferable to implement a read-only property and when is a function better? I can't offer rigid rules, just a few suggestions:
NOTE
What happens when you try to assign a value to a read-only property is slightly different from when you try to assign to a function. In the former case, you receive a plain error—"Can't assign to read-only property"—whereas in the latter, you get a more cryptic "Function call on left-hand side of assignment must return Variant or Object." The real meaning of this strange message will be clear when I cover object properties later in this chapter.
Let's take the CompleteName member of the CPerson class as an example. It has been implemented as a method, but most programmers would undoubtedly think of it as a read-only property. Moreover—and this is the really important point—nothing prevents you from morphing it into a read/write property:
Property Get CompleteName() As String CompleteName = FirstName &; " " &; LastName End Property Property Let CompleteName(ByVal newValue As String) Dim items() As String items() = Split(newValue) ' We expect exactly two items (no support for middle names). If UBound(items) <> 1 Then Err.Raise 5 ' If no error, assign to the "real" properties. FirstName = items(0): LastName = items(1) End Property |
You have increased the usability of the class by letting the client code assign theFirstName and LastName properties in a more natural way, for example, directly from a field on the form:
pers.CompleteName = txtCompleteName.Text |
And of course you can still assign individual FirstName and LastName properties without the risk of creating inconsistencies with the CompleteName property. This is another of those cute little things you can do with classes.
So far, I've illustrated Property Get procedures with no arguments and their matching Property Let procedures with just one argument, the value being assigned to the procedure. Visual Basic also lets you create Property procedures that accept any number of arguments, of any type. This concept is also used by Visual Basic for its own controls: for example, the List property of ListBox controls accepts a numerical index.
Let's see how this concept can be usefully applied to the CPerson sample class. Suppose you need a Notes property, but at the same time you don't want to limit yourself to just one item. The first solution that comes to mind is using an array of strings. Unfortunately, if you declare a Public array in a class module as follows:
Public Notes(1 To 10) As String ' Not valid! |
the compiler complains with the following message, "Constants, fixed-length strings, arrays, user-defined types, and Declare statements not allowed as Public member of object modules." But you can create a Private member array and expose it to the outside using a pair of Property procedures:
' A module-level variable Private m_Notes(1 To 10) As String Property Get Notes(Index As Integer) As String Notes = m_Notes (Index) End Property Property Let Notes(Index As Integer, ByVal newValue As String) ' Check for subscript out of range error. If Index < LBound(m_Notes) Or Index > UBound(m_Notes) Then Err.Raise 9 m_Notes(Index) = newValue End Property |
CAUTION
You might be tempted not to check the Index argument in the Property Let procedure in the preceding code, relying instead on the default behavior of Visual Basic that would raise an error anyway. Think about it again, and try to imagine what would happen if you later decide to optimize your code by setting the Remove Array Bounds Checks optimization for the compiler. (The answer is easy: Can you spell "G-P-F"?)
Now you can assign and retrieve up to 10 distinct notes for the same person, as in this code:
pers.Notes(1) = "Ask if it's OK to go fishing next Sunday" Print pers.Notes(2) ' Displays "" (not initialized) |
You can improve this mechanism by making Index an optional argument that defaults to the first item in the array, as in the following code:
Property Get Notes(Optional Index As Integer = 1) As String ' ... (omitted: no need to change code inside the procedure) End Property Property Let Notes(Optional Index As Integer = 1, _ ByVal newValue As String) ' ... (omitted : no need to change code inside the procedure) End Property ' In the client code, you can omit the index for the default note. pers.Notes = "Ask if it's OK to go fishing next Sunday" ' You can always display all notes with a simple For-Next loop. For i = 1 To 10: Print pers.Notes(i): Next |
You can also use optional Variant arguments and the IsMissing function, as you would do for regular procedures in a form or standard module. In practice, this is rarely required, but it's good to know that you can do it if you need to.
I have already described the convenience of using Property Get and Let procedures instead of plain Public variables in a class: You get more control, you can validate data assigned to the property, you can trace the execution flow, and so on. But here's one more interesting detail that you should be aware of. Even if you declare a Public variable, what Visual Basic actually does is create a hidden pair of Property procedures for you and calls them whenever you reference the property from outside the class:
' Inside the CPerson class Public Height As Single ' Height in inches ' Outside the class pers.Height = 70.25 ' This calls a hidden Property Let procedure. |
Apart from a slight performance hit—invoking a procedure is surely slower than accessing a variable—this Visual Basic behavior doesn't appear to be a detail worth mentioning. Unfortunately, this isn't the case. Let's suppose that you want to convert all your measurements into centimeters, so you prepare a simple procedure that does the job with its ByRef argument:
' In a standard BAS module Sub ToCentimeters (value As Single) ' Value is received by reference, therefore it can be changed. value = value * 2.54 End Sub |
You think you can legitimately expect an easy conversion for your objects' properties, as follows:
ToCentimeters pers.Height ' Doesn't work! |
The reason the preceding approach fails should be clear, now that you know that Public variables are implemented as hidden procedures. In fact, when you pass the pers.Height value to the ToCentimeters procedure you're passing the result of an expression, not an actual memory address. Therefore, the routine has no address to which to deliver the new value, and the result of the conversion is lost.
CAUTION
Microsoft changed the way the Public variables in class modules are implemented. In Visual Basic 4, these variables weren't encapsulated in hidden property procedures; therefore, they could be modified if passed to a procedure through a ByRef argument. This implementation detail changed when Visual Basic 5 was released, and many Visual Basic 4 programmers had to rework their code to comply with the new style by creating a temporary variable that actually receives the new value:
' The fix that VB4 developers had to apply when porting to VB5 Dim temp As Single temp = pers.Height ToCentimeter temp pers.Height = tempThis code is neither elegant nor efficient. Worse, since this technique isn't clearly documented, many programmers had to figure it out on their own. If you are about to port some Visual Basic 4 code to versions 5 or 6, don't be caught off guard.
Anyway, here's yet another untold episode of the story. What I have described so far is what happens when you reference the Public variable from outside the class. If you invoke the external procedure from inside the class module and pass it your variable, everything works as expected. In other words, you can write this code in the CPerson class:
' Inside the CPerson class ToCentimeter Height ' It works! |
and the Height property will be correctly updated. In this case, the value passed is the address of the variable, not the return value of a hidden procedure. This point is important if you want to move code from outside the class to inside the class (or vice versa) because you must be prepared to deal with subtle issues like this one.
CAUTION
One last note, just to add confusion to confusion: If you prefix the properties in your class module with the Me keyword, they're again seen as properties instead of variables and Visual Basic invokes the hidden procedure instead of using the variable address. Therefore, this code won't work even inside the class module:
ToCentimeter Me.Height ' It doesn't work!
You already know a lot about methods. There are, however, a few more interesting details that you should be aware of concerning how methods can be used within a class module.
Let's say that you have a function that returns a complex value—for example, the grand total of an invoice—and you don't want to reevaluate it each time the client code makes a request. On the other hand, you don't want to store it somewhere and run the risk that its value becomes obsolete because some other property of the invoice changes. This is similar to the decision that a database developer has to make: Is it better to create a GrandTotal field that contains the actual value (thus putting the consistency of the database at stake and also wasting some disk space) or to evaluate the total each time you need it (thus wasting CPU time each time you do it)?
Class modules offer a simple and viable alternative that applies equally well to all dependent values, be they implemented as functions or read-only properties. As an example, reconsider the ReverseName function in the CPerson class, and pretend that it takes a lot of processing time to evaluate its result. Here's how you can modify this function to keep the overhead to a minimum without modifying the interface that the class exposes to the outside. (Added statements are in boldface.)
' A private member variable Private m_ReverseName As Variant Property Let FirstName(ByVal newValue As String) ' Raise an error if an invalid assignment is attempted. If newValue = "" Then Err.Raise 5 ' Invalid procedure argument ' Else store in the Private member variable. m_FirstName = newValue m_ReverseName = Empty End Property Property Let LastName(ByVal newValue As String) ' Raise an error if an invalid assignment is attempted. If newValue = "" Then Err.Raise 5 ' Invalid procedure argument ' Else store in the Private member variable. m_LastName = newValue m_ReverseName = Empty End Property Function ReverseName() As String If IsEmpty(m_ReverseName) Then m_ReverseName = LastName &; ", " &; FirstName End If ReverseName = m_ReverseName End Function |
In other words, you store the return value in a Private Variant variable before returning to the client and reuse that value if possible in all subsequent calls. The trick works because each time either FirstName or LastName (the independent properties) are assigned a new value, the Private variable is cleared, which forces it to be reevaluated the next time the ReverseName function is invoked. Examine this simple client code and try to figure out how difficult it would have been to implement equivalent logic using other techniques:
' This line takes some microseconds the first time it is executed. If pers.ReverseName <> "Smith, John" Then ' If this line is executed, it internally resets m_ReverseName. pers.FirstName = "Robert" End If ' In all cases, the next statement will be as fast as possible. Print pers.ReverseName |
Of course, we might have also reevaluated the m_ReverseName value right in the Property Let procedures of FirstName and LastName, but that would undermine our main purpose, which is to avoid unnecessary overhead or postpone it as long as possible. In a real-world application, this difference might involve unnecessarily opening a database, reestablishing a remote connection, and so on, so it's apparent that the advantages of this technique shouldn't be underestimated.
So far, I've explained that a class can be considered robust if it always contains valid data. The primary way to achieve this goal is to provide Property procedures and methods that permit the outside code to transform the internal data only from one valid state to another valid state. In this reasoning, however, is a dangerous omission: What happens if an object is used immediately after its creation? You can provide some useful initial and valid values in the Class_Initialize event procedure, but this doesn't ensure that all the properties are in a valid state:
Set pers = New CPerson Print pers.CompleteName ' Displays an empty string. |
In more mature OOPLs such as C++, this issue is solved by the language's ability to define a constructor method. A constructor method is a special procedure defined in the class module and executed whenever a new instance is created. Because you define the syntax of the constructor method, you can force the client code to pass all the values that are needed to create the object in a robust state from its very beginning, or refuse to create the object at all if values are missing or invalid.
Alas, Visual Basic completely lacks constructor methods, and you can't prevent users of your class from using the object as soon as they create it. The best you can do is create a pseudo-constructor method that correctly initializes all the properties and let other programmers know that they can initialize the object in a more concise and robust way:
Friend Sub Init(FirstName As String, LastName As String) Me.FirstName = FirstName Me.LastName = LastName End Sub |
Your invitation should be gladly accepted because now the client code can initialize the object in fewer steps:
Set pers = New CPerson pers.Init "John", "Smith" |
Two issues are worth noting in the preceding code. First, the scope of the method is Friend: This doesn't make any difference in this particular case, but it will become important when and if the class becomes Public and accessible from the outside, as we'll see in Chapter 16. In Standard EXE projects, Friend and Public are synonymous; using the former doesn't hurt, and you'll save a lot of work if you later decide to transform the project into an ActiveX component.
The second noteworthy point is that arguments have the same names as the properties they refer to, which makes our pseudo-constructor easier to use for the programmer who already knows the meaning of each property. To avoid a name conflict, inside the procedure you refer to the real properties using the Me keyword. This is slightly less efficient but preserves the data encapsulation and ensures that any validation code will be properly executed when the constructor routine assigns a value to properties.
The concept of constructors can be refined by using optional arguments. The key properties of our CPerson class are undoubtedly FirstName and LastName, but in many cases the client code will also set BirthDate and ID. So why not take this opportunity to make life easier for the programmer who uses the class?
Friend Sub Init(FirstName As String, LastName As String, _ Optional ID As Variant, Optional BirthDate As Variant) Me.FirstName = FirstName Me.LastName = LastName If Not IsMissing(ID) Then Me.ID = ID If Not IsMissing(BirthDate) Then Me.BirthDate = BirthDate End Sub |
In this case, you must adopt optional arguments of type Variant because it is essential that you use the IsMissing function and bypass the assignment of values that were never provided by the client:
pers.Init "John", "Smith", , "10 Sept 1960" |
You can do one more thing to improve the class's usability and acceptance by other programmers. This point is really important because if you convince the user of your class to call the constructor you provide—and you must choose this "softer" approach, since you can't force them to—your code and the entire application will be more robust. The trick I'm suggesting is that you write a constructor function in a BAS module in your application:
Public Function New_CPerson(FirstName As String, LastName As String, _ Optional ID As Variant, Optional BirthDate As Variant) As CPerson ' You don't even need a temporary local variable. Set New_CPerson = New CPerson New_CPerson.Init FirstName, LastName, ID, BirthDate End Function |
Procedures of this type are sometimes called factory methods. Now see how this can streamline the portion of the client code that creates an instance of the class:
Dim pers As CPerson ' Creation, initialization, and property validation in one step! Set pers = New_CPerson("John", "Smith", , "10 Sept 1960") |
TIP
You can reduce the typing and the guesswork when using these sort-of constructors if you gather them into a single BAS module and give this module a short name, such as Class or Factory. (You can't use New, sorry.) Then when you need to type the name of a constructor method, you just type Class and let Microsoft IntelliSense guide you through the list of constructor methods contained in that module. You can use this approach anytime you don't remember the name of a procedure in a module.
Creating all your objects by means of explicit constructors has other benefits as well. For example, you can easily add some trace statements in the New_CPerson routine that keeps track of how many objects were created, the initial values of properties, and so on. Don't underestimate this capability if you're writing complex applications that use many class modules and object instances.
I want to tell you more about properties that can make your classes even more useful and powerful.
Some properties are intended to return a well-defined subset of integer numbers. For example, you could implement a MaritalStatus property that can be assigned the values 1 (NotMarried), 2 (Married), 3 (Divorced), and 4 (Widowed). The best solution possible under Visual Basic 4 was to define a group of constants and consistently use them in the code both inside and outside the class. This practice, however, forced the developer to put the CONST directives in a separate BAS module, which broke the self-containment of the class.
Visual Basic 5 solved this issue by adding a new Enum keyword to the VBA language and thus the ability to create enumerated values. An Enum structure is nothing but a group of related constant values that automatically take distinct values:
' In the declaration section of the class Enum MaritalStatusConstants NotMarried = 1 Married Divorced Widowed End Enum |
You don't need to assign an explicit value to all the items in the Enum structure: for all the subsequent omitted values, Visual Basic just increments the preceding value. (So in the previous code, Married is assigned the value 2, Divorced is 3, and so on.) If you also omit the first value, Visual Basic starts at 0. But because 0 is the default value for any integer property when the class is created, I always prefer to stay clear of it so that I can later trap any value that hasn't been initialized properly.
After you define an Enum structure, you can create a Public property of the corresponding type:
Private m_MaritalStatus As MaritalStatusConstants Property Get MaritalStatus() As MaritalStatusConstants MaritalStatus = m_MaritalStatus End Property Property Let MaritalStatus(ByVal newValue As MaritalStatusConstants) ' Refuse invalid assignments. (Assumes that 0 is always invalid.) If newValue <= 0 Or newValue > Widowed Then Err.Raise 5 m_MaritalStatus = newValue End Property |
The benefit of using enumerated properties becomes apparent when you write code that uses them. In fact, thanks to IntelliSense, as soon as you press the equal sign key (or use any other math or Boolean operator, for that matter), the Visual Basic editor drops down a list of all the available constants, as you can see in Figure 6-3. Moreover, all the Enums you define immediately appear in the Object Browser, so you can check the actual value of each individual item there.
Figure 6-3. Use IntelliSense to speed up your typing when working with Enum properties.
Here are a few details you must account for when dealing with Enum values:
Figure 6-4. You can't use Enum values in a Public class if the Enum block is located in a form module, in a Private class, or in a standard BAS module.
The last point is especially important, and I strongly advise you to devise a method for generating unique names for all your enumerated constants. If you fail to do that, the compiler refuses to compile your application and raises the "Ambiguous name detected: <itemname>" error. The easy way to avoid this problem is to add to all the enumerated constants a unique 2- or 3-letter prefix, for example:
Enum SexConstants sxMale = 1 sxFemale End Enum |
The other way to avoid trouble is to use the complete enumname.constantname syntax whenever you refer to an ambiguous Enum member, as in the following code:
pers.MaritalStatus = MaritalStatusConstants.Married |
Enum values don't need to be in an increasing sequence. In fact, you can provide special values out of the expected order to signal some special conditions, as in the following code:
' In a hypothetical Order class Enum OrderStatusConstants osShipping = 1 osBackOrder osError = -9999 ' Tip: use negative values for such special cases. End Enum |
Another common example of enumerated properties whose values aren't in sequence are bit-fielded properties, as in this code:
Enum FileAttributeConstants Normal = 0 ' Actually means "no bit set" ReadOnly = 1 ' Bit 0 Hidden = 2 ' Bit 1 System = 4 ' Bit 2 Directory = 16 ' Bit 3 Archive = 32 ' Bit 4 End Enum |
While enumerated properties are very useful and permit you to store some descriptive information in just 4 bytes of memory, you shouldn't forget that sooner or later you'll have to extract and interpret this information and sometimes even show it to your users. For this reason, I often add to my classes a read-only property that returns the textual description of an enumerated property:
Property Get MaritalStatusDescr() As String Select Case m_MaritalStatus Case NotMarried: MaritalStatusDescr = "NotMarried" Case Married: MaritalStatusDescr = "Married" Case Divorced: MaritalStatusDescr = "Divorced" Case Widowed If Sex = Male Then ' Be precise for your users. MaritalStatusDescr = "Widower" ElseIf Sex = Female Then MaritalStatusDescr = "Widow" End If Case Else Err.Raise 5 ' Defensive programming! End Select End Property |
It seems a lot of work for such a little piece of information, but you'll be glad that you did it every time you have to show the information on screen or in a printed report. You might wonder why I added a Case Else block (shown in boldface). After all, the m_MaritalStatus variable can't be assigned a value outside its range because it's protected by the Property Let MaritalStatus procedure, right? But you should never forget that a class is often an evolving entity, and what's true today might change tomorrow. All the validation code that you use for testing the valid range of such properties might become obsolete without your even noticing it. For example, what happens if you later add a fifth MaritalStatus constant? Are you really going to hunt through your code for possible bugs each and every time you add a new enumerated value? Explicitly testing all the values in a Select Case block and rejecting those that fall through the Case Else clause is a form of defensive programming that you should always exercise if you don't want to spend more time debugging the code later.
Here's an easy trick that lets you safely add new constants without also modifying the validation code in the corresponding Property Let procedure. Instead of testing against the largest constant, just define it explicitly in the Enum structure:
Enum MaritalStatusConstants NotMarried = 1 Married Divorced Widowed MARITALSTATUS_MAX = Widowed ' Uppercase is easier to spot. End Enum Property Let MaritalStatus(ByVal newValue As MaritalStatusConstants) ' Refuse invalid assignments. (Assumes that 0 is always invalid.) If newValue <= 0 Or newValue > MARITALSTATUS_MAX Then Err.Raise 5 m_MaritalStatus = newValue End Property |
When you then append constants to the Enum block, you need to make the MARITALSTATUS_MAX item point to the new highest value. If you add a comment, as in the preceding code, you can hardly miss it.
Visual Basic's own objects might expose properties that return object values. For example, forms and all visible controls expose a Font property, which in turn returns a Font object. You realize that this is a special case because you can append a dot to the property name and have IntelliSense tell you the names of the properties of the object:
Form1.Font.Bold = True |
What Visual Basic does with its own objects can also be done with your custom classes. This adds a great number of possibilities to your object-oriented programs. For example, your CPerson class still lacks an Address property, so it's time to add it. In most cases, a single Address string doesn't suffice to point exactly to where a person lives, and you usually need several pieces of related information. Instead of adding multiple properties to the CPerson object, create a new CAddress class:
' The CAddress class module Public Street As String Public City As String Public State As String Public Zip As String Public Country As String Public Phone As String Const Country_DEF = "USA" ' A default for Country property Private Sub Class_Initialize() Country = Country_DEF End Sub Friend Sub Init(Street As String, City As String, State As String, _ Zip As String, Optional Country As Variant, Optional Phone As Variant) Me.Street = Street Me.City = City Me.State = State Me.Zip = Zip If Not IsMissing(Country) Then Me.Country = Country If Not IsMissing(Phone) Then Me.Phone = Phone End Sub Property Get CompleteAddress() As String CompleteAddress = Street &; vbCrLf &; City &; ", " &; State &; " " &; Zip _ &; IIf(Country <> Country_DEF, Country, "") End Property |
For the sake of simplicity, all properties have been declared Public items, so this class isn't particularly robust. In a real-world example, a lot of nice things could be done to make this class a great piece of code, such as checking that the City, State, and Zip properties are compatible with one another. (You probably need a lookup search against a database for this.) You could even automatically provide an area code for the Phone property. I gladly leave these enhancements as an exercise to readers. For now, let's focus on how you can exploit this new class together with CPerson. Adding a new HomeAddress property to our CPerson class requires just one line of code in the declaration section of the module:
' In the CPerson class module Public HomeAddress As CAddress |
Now you can create a CAddress object, initialize its properties, and then assign it to the HomeAddress property just created. Thanks to the Init pseudo-constructor, you can considerably reduce the amount of code that's actually needed in the client:
Dim addr As CAddress Set addr = New CAddress addr.Init "1234 North Rd", "Los Angeles", "CA", "92405" Set pers.HomeAddress = addr |
While this approach is perfectly functional and logically correct, it's somehow unnatural. The problem stems from having to explicitly create a CAddress object before assigning it to the HomeAddress property. Why not work directly with the HomeAddress property?
Set pers.HomeAddress = New CAddress pers.HomeAddress.Init "1234 North Rd", "Los Angeles", "CA", "92405" |
When you work with nested object properties, you'll like the With...End With clause:
With pers.HomeAddress .Street = "1234 North Rd" .City = "Los Angeles" ' etc. End With |
As I showed you previously, you can provide an independent constructor method in a standard BAS module (not shown here) and do without a separate Set statement:
Set pers.HomeAddress = New_CAddress("1234 North Rd", "Los Angeles", _ "CA", "92405") |
A minor problem that you have to face is the lack of control over what can be assigned to the HomeAddress property. How can you be sure that no program will compromise the robustness of your CPerson object by assigning an incomplete or invalid CAddress object to the HomeAddress property? And what if you need to make the HomeAddress property read-only?
As you see, these are the same issues that you faced when working with regular, nonobject properties, which you resolved thanks to Property Get and Property Let procedures. So it shouldn't surprise you to learn that you can do the same with object properties. The only difference is that you use a third type of property procedure, the Property Set procedure, instead of the Property Let procedure:
Dim m_HomeAddress As CAddress ' A module-level private variable. Property Get HomeAddress() As CAddress Set HomeAddress = m_HomeAddress End Property Property Set HomeAddress(ByVal newValue As CAddress) Set m_HomeAddress = newValue End Property |
Because you're dealing with object references, you must use the Set keyword in both procedures. A simple way to ensure that the CAddress object being assigned to the HomeAddress property is valid is to try out its Init method with all the required properties:
Property Set HomeAddress(ByVal newValue As CAddress) With newValue .Init .Street, .City, .State, .Zip End With ' Do the assignment only if the above didn't raise an error. Set m_HomeAddress = newValue End Property |
Unfortunately, protecting an object property from invalid assignments isn't as simple as it appears. If the innermost class—CAddress in this case—doesn't protect itself in a robust way, the outermost class can do little or nothing. To explain why, just trace this apparently innocent statement:
pers.HomeAddress.Street = "" ' An invalid assignment raises no error. |
At first, you might be surprised to see that execution doesn't flow through the Property Set HomeAddress procedure; instead, it goes through the Property Get HomeAddress procedure, which seems nonsensical because we are assigning a value, not reading it. But if we look at the code from a compiler's standpoint, things are different. The language parser scans the line from left to right: it first finds a reference to a property exposed by the CPerson class (that is, pers.HomeAddress) and tries to resolve it to determine what it's pointing to. For this reason, it has to evaluate the corresponding Property Get procedure. The result is that you can't effectively use the Property Get HomeAddress procedure to protect the CPerson class module from invalid addresses: you must protect the CAddress dependent class itself. In a sense, this is only fair because each class should be responsible for itself.
Let's see how you can use the CAddress class to improve the CPerson class even further. You have already used it for the HomeAddress property, but there are other possible applications:
' In the declaration section of CPerson Private m_WorkAddress As CAddress Private m_VacationAddress As CAddress ' Corresponding Property Get/Set are omitted here.... |
It's apparent that you have achieved a lot of functionality with minimal effort. Not only have you dramatically reduced the amount of code in the CPerson class (you need only three pairs of Property Get/Set procedures), you also simplified its structure because you don't have a large number of similar properties with confusing names (HomeAddressStreet, WorkAddressStreet, and so on). But above all, you have the logic for the CAddress entity in one single place, and it has been automatically propagated elsewhere in the application, without your having to set up distinct validation rules for each distinct type of address property. Once you have assigned all the correct addresses, see how easy it is to display all of them:
On Error Resume Next ' The error handler skips unassigned (Nothing) properties. Print "Home: " &; pers.HomeAddress.CompleteAddress Print "Work: " &; pers.WorkAddress.CompleteAddress Print "Vacation: " &; pers.VacationAddress.CompleteAddress |
Properties that return Variant values aren't different from other properties: You just need to declare a Public Variant member and you're done. But things are a bit more complex if the property can receive either a regular value or an object value. For example, say that you want to implement a CurrentAddress property, but you want to keep it more flexible and capable of storing either a CAddress object or a simpler string, as in this code:
' The client code can assign a regular string pers.CurrentAddress = "Grand Plaza Hotel, Rome" ' or a reference to another CAddress object (requires Set). Set pers.CurrentAddress = pers.VacationAddress |
While this sort of flexibility adds a lot of power to your class, it also reduces its robustness because nothing keeps a programmer from adding a nonstring value or an object of a class other than CAddress. To be more in control of what is actually assigned to this property, you need to arbitrate all accesses to it through Property procedures. But in this case, you need three distinct Property procedures:
Private m_CurrentAddress As Variant Property Get CurrentAddress() As Variant If IsObject(m_CurrentAddress) Then Set CurrentAddress = m_CurrentAddress ' Return a CAddress object. Else CurrentAddress = m_CurrentAddress ' Return a string. End If End Property Property Let CurrentAddress(ByVal newValue As Variant) m_CurrentAddress = newValue End Property Property Set CurrentAddress(ByVal newValue As Variant) Set m_CurrentAddress = newValue End Property |
The Property Let procedure is invoked when a regular value is assigned to the property, while the Property Set procedure comes into play when the client assigns an object with a Set command. Note how the Property Get procedure returns a value to the caller code: It has to test whether the private variable currently contains an object, and it must use a Set command if it does. The Property Let and Set pair lets you enforce a better validation scheme:
Property Let CurrentAddress(ByVal newValue As Variant) ' Check that it is a string value. If VarType(newValue) <> vbString Then Err.Raise 5 m_CurrentAddress = newValue End Property Property Set CurrentAddress(ByVal newValue As Variant) ' Check that it is a CAddress object. If TypeName(newValue) <> "CAddress" Then Err.Raise 5 Set m_CurrentAddress = newValue End Property |
Here's a technique that lets you save some code and slightly improve run-time performances. The trick is to declare the type of the object you're expecting right in the parameter list of the Property Set procedure, as in this code:
Property Set CurrentAddress(ByVal newValue As CAddress) Set m_CurrentAddress = newValue End Property |
You can't use this approach in all circumstances; for example, you can't use it when you're willing to accept two or more objects of different types. In that case, it's best to use an As Object parameter:
Property Set CurrentAddress(ByVal newValue As Object) If TypeName(newValue) <> "CAddress" And TypeName(newValue) <> _ "COtherType" Then Err.Raise 5 Set m_CurrentAddress = newValue End Property |
As far as Visual Basic is concerned, the real type is determined by the value declared in the Property Get procedure. In fact, that's the type reported in the Object Browser.
While this fact is undocumented in Visual Basic manuals, you can create Property procedures in standard BAS modules as well. This capability makes a few interesting techniques possible. You can use a pair of Property procedures to encapsulate a global variable and arbitrate all accesses to it. Let's say that you have a global Percent variable:
' In a standard BAS module Public Percent As Integer |
For really robust code, you want to be sure that all values assigned to it are in the proper 0 through 100 range, but you don't want to test all the assignment statements in your code. The solution is easy, as you'll see in the next section.
Dim m_Percent As Integer Property Get Percent() As Integer Percent = m_Percent End Property Property Let Percent(newValue As Integer) If newValue < 0 Or newValue > 100 Then Err.Raise 5 m_Percent = newValue End Property |
Other interesting variations of this technique are read-only and write-once/read-many global variables. You can also use this technique to work around the inability of Visual Basic to declare string constants that contain Chr$ functions and concatenation operators:
' You can't do this with a CONST directive. Property Get DoubleCrLf() As String DoubleCrLf = vbCrLf &; vbCrLf End Property |
Finally, you can use Property procedures in BAS modules to trace what happens to the global variables in your code. Let's say that your code incorrectly assigns a wrong value to a global variable, and you want to understand when this happens. Just replace the variable with a pair of Property procedures, and add Debug.Print statements as required (or print values to a file, if you want). When you have fixed all the problems, delete the procedures and restore the original global variable. The best thing about all this is that you won't need to edit a single line of code in the rest of your application.
Visual Basic 6 includes a welcome addition to the VBA language, in the form of the CallByName function. This keyword lets you reference an object's method or property by passing its name in an argument. Its syntax is as follows:
result = CallByName(object, procname, calltype [,arguments..]) |
where procname is the name of the property or method, and calltype is one of the following constants: 1-vbMethod, 2-vbGet, 4-vbLet, 8-vbSet. You must pass any argument the method is expecting, and you should avoid retrieving a return value if you're invoking a Sub method or a Property Let/Get procedure. Here are a few examples:
Dim pers As New CPerson ' Assign a property. CallByName pers, "FirstName", vbLet, "Robert" ' Read it back. Print "Name is " &; CallByName(pers, "FirstName", vbGet) ' Invoke a function method with one argument. width = CallByName(Form1, "TextWidth", vbMethod, "ABC") |
Here are a couple of noteworthy bits of information about this function, both of which affect its usefulness:
As a general rule, you should never use the CallByName function when you can reach the same result using the regular dot syntax. There are times, however, when this function permits you to write very concise and highly parameterized code. One interesting application is quickly setting a large number of properties for controls on a form. This might be useful when you give your users the ability to customize a form and you then need to restore the last configuration in the Form_Load event. I have prepared a couple of reusable procedures that do the job:
' Returns an array of "Name=Values" strings ' Supports only nonobject properties, without indices Function GetProperties(obj As Object, ParamArray props() As Variant) _ As String() Dim i As Integer, result() As String On Error Resume Next ' Prepare the result array. ReDim result(LBound(props) To UBound(props)) As String ' Retrieve all properties in turn. For i = LBound(props) To UBound(props) result(i) = vbNullChar ' If the call fails, this item is skipped. result(i) = props(i) &; "=" &; CallByName(obj, props(i), vbGet) Next ' Filter out invalid lines. GetProperties = Filter(result(), vbNullChar, False) End Function ' Assign a group of properties in one operation. ' Expects an array in the format returned by GetProperties Sub SetProperties(obj As Object, props() As String) Dim i As Integer, temp() As String For i = LBound(props) To UBound(props) ' Get the Name-Value components. temp() = Split(props(i), "=") ' Assign the property. CallByName obj, temp(0), vbLet, temp(1) Next End Sub |
When you're using GetProperties, you have to provide a list of the properties you're interested in, but you don't need a list when you restore the properties with a call to SetProperties:
Dim saveprops() As String saveprops() = GetProperties(txtEditor, "Text", "ForeColor", "BackColor") ... SetProperties txtEditor, saveprops() |
You can't entirely define a class in the code window. In fact, you must specify a few important attributes in a different way. These attributes might concern the class module as a whole or its individual members (that is, its properties and methods).
The attributes of the class module itself are conceptually simpler because you can edit them through the Properties window, as you might for any other source code module that can be hosted in the Visual Basic environment. But in contrast to what happens with form and standard modules, what you see in the Properties window when you press the F4 key depends on the type of the project. (See Figure 6-5.) There are six attributes: Name, DataBindingBehavior, DataSourceBehavior, Instancing, MTSTransactionMode, and Persistable. They will be covered in detail in subsequent chapters.
Figure 6-5. Only a Public class module in an ActiveX DLL project exposes all possible class attributes in the Properties window.
Most Visual Basic controls and intrinsic objects expose a default property or method. For example, the TextBox control's default property is Text; the Error object's default property is Number; Collections have a default Item method; and so on. Such items are said to be default members because if you omit the member name in an expression, Visual Basic will implicitly assume you meant to refer to that particular member. You can implement the same mechanism even with your own classes by following this procedure:
Figure 6-6. Selecting the Properties menu command from the Object Browser.
Figure 6-7. The expanded view of the Procedure Attributes dialog box.
A class can't expose more than one default property or method. If you try to create a second default item, Visual Basic complains and asks you to confirm your decision. In general, it isn't a good idea to change the default member of a class because this amendment could break all the client code written previously.
While I certainly agree that providing a default property to a class module tends to make it more easily usable, I want to warn you against some potential problems that can arise from using this feature. Let's go back to our CPerson class and its HomeAddress and WorkAddress properties. As you know, you can assign one object property to another, as in this code:
Set pers.HomeAddress = New CAddress Set pers.WorkAddress = New CAddress pers.HomeAddress.Street = "2233 Ocean St." ... Set pers.WorkAddress = pers.HomeAddress ' This person works at home. |
Since the preceding code uses the Set command, both properties are actually pointing to the same CAddress object. This is important because it implies that no additional memory has been allocated to store this duplicate data and also because you can then freely modify the address properties through any of the two CPerson properties without introducing any inconsistencies:
pers.HomeAddress.Street = "9876 American Ave" Print pers.WorkAddress.Street ' Correctly displays "9876 American Ave" |
Now see what happens if you mistakenly omit the Set keyword in the original assignment:
pers.WorkAddress = pers.HomeAddress ' Error 438 "Object doesn't support ' this property or method" |
Don't be alarmed by this (admittedly cryptic) error message: You made a logic error in your code, and Visual Basic has spotted it for you at run time, which is a good thing. Alas, this helpful error disappears if the class exposes a default property. To see it for yourself, make Street the default item of the class, and then run this code:
Set pers.HomeAddress = New CAddress Set pers.WorkAddress = New CAddress pers.HomeAddress.Street = "2233 Ocean St." pers.WorkAddress = pers.HomeAddress ' No error! But has it worked? |
Instead of rejoicing about the absence of an error message, see how the two properties are now related to each other:
'Change the Street property of one object. pers.HomeAddress.Street = "9876 American Ave" Print pers.WorkAddress.Street ' Still displays "2233 Ocean St." |
In other words, the two properties aren't pointing to the same object anymore. The assignment without the Set command has cheated the compiler into thinking that we were asking it to assign the values of the default Street property (which is a legal operation) and that we weren't interested in creating a new reference to the same object.
In short, by adding a default property you have deprived yourself of an important cue about the correctness of your code. My personal experience is that missing Set commands are subtle bugs that are rather difficult to exterminate. Keep this in mind when you're deciding to create default properties. And if you're determined to create them, always double-check your Set keywords in code.
CAUTION
You might notice that if the object property on the left side of the assignment is Nothing, Visual Basic correctly raises error 91 even if we omit the Set keyword. This doesn't happen, however, if the target property had been declared to be auto-instancing because in that case Visual Basic would create an object for you. This is just further proof that auto-instancing objects should always be looked at with suspicion.
Now that I have warned you against using default properties, I want to show you a case in which they could turn out to be very useful. But first I need to introduce the concept of sparse matrices. A sparse matrix is a large, two-dimensional (or multidimensional) array that includes a relatively small number of nonzero items. A 1000-by-1000 array with just 500 nonzero items can be considered a great example of a sparse matrix. Sparse matrices have several common applications in math and algebra, but you can also find a use for them in business applications. For example, you might have a list of 1000 cities and a two-dimensional array that stores the distance between any two cities. Let's assume further that we use 0 (or some other special value) for the distance between cities that aren't directly connected. Large sparse arrays raise a serious memory overhead problem: a two-dimensional array of Single or Long values with 1000 rows and 1000 columns takes nearly 4 MB, so you can reasonably expect that it will noticeably slow your application on less powerful machines.
One simple solution to this problem is to store only the nonzero elements, together with their row and column indices. You need 8 additional bytes for each element to do this, but in the end you're going to save a lot of memory. For example, if only 10,000 items are nonzero (filling factor = 1:100), your sparse array will consume less than 120 KB—that is, about 33 times less than the original array—so this seems to be a promising approach. You might believe that implementing a sparse array in Visual Basic requires quite a bit of coding, so I bet you'll be surprised to learn how simple it is when you're using a class module:
' The complete source code of the CSparseArray class Private m_Value As New Collection Property Get Value(row As Long, col As Long) As Single ' Returns an item, or 0 if it doesn't exist. On Error Resume Next Value = m_Value(GetKey(row, col)) End Property Property Let Value(row As Long, col As Long, newValue As Single) Dim key As String key = GetKey(row, col) ' First destroy the value if it's in the collection. On Error Resume Next m_Value.Remove key ' Then add the new value, but only if it's not 0. If newValue <> 0 Then m_Value.Add newValue, key End Property ' A private function that builds the key for the private collection. Private Function GetKey(row As Long, col As Long) As String GetKey = row &; "," &; col End Function |
Make sure that the Value property—the only public member of this class—is also its default property, which dramatically simplifies how the client uses the class. See how easy it is using your brand-new, resource-savvy data structure instead of a regular matrix:
Dim mat As New CSparseArray ' The rest of the application that uses the matrix isn't unchanged. mat(1, 1) = 123 ' Actually using mat's Value property! |
In other words, thanks to a default property you have been able to change the inner workings of this application (and, it's to be hoped, to optimize it too) by changing only one line in the client code! This should be a convincing argument in favor of default properties.
Actually, the CSparseArray class is even more powerful than it appears. In fact, while its original implementation uses Long values for the row and col arguments and a Single return value, you might decide to use Variant values for the two indices and for the return value. This first amendment permits you to create arrays that use strings as indices to data with no effort, as in this code:
' The distance between cities Dim Distance As New CSparseArray Distance("Los Angeles", "San Bernardino") = 60 |
Using a Variant return type doesn't waste more memory than before because the internal m_Values collection allocates a Variant for each value anyway.
Before concluding this section, let me hint at another special type of array, the so-called symmetrical array. In this type of two-dimensional array, m(i,j) always matches m(j,i), so you can always save some memory by storing the value just once. The Distance matrix is a great example of a symmetrical array because the distance between two cities doesn't depend on the order of the cities themselves. When you're dealing with a regular Visual Basic array, it's up to you to remember that it's symmetrical and that you must therefore store the same value twice, which means more code, memory, and chances for errors. Fortunately, now that you have encapsulated everything in a class module, you just need to edit one private routine:
' Note that row and col are now Variants. Private Function GetKey(row As Variant, col As Variant) As String ' Start with the lesser of the two--a case-insensitive comparison ' is needed because collections search their keys in this way. If StrComp(row, col, vbTextCompare) < 0 Then ' Using a nonprintable delimiter is preferable. GetKey = row &; vbCr &; col Else GetKey = col &; vbCr &; row End If End Function |
This is enough to make this client code work as expected:
Dim Distance As New CSparseMatrix Distance("Los Angeles", "San Bernardino") = 60 Print Distance("San Bernardino", "Los Angeles") ' Displays "60" |
You might have noticed that the Procedure Attributes dialog box in Figure 6-7 contains many more fields than I have explained so far. The majority of the corresponding attributes are somewhat advanced and won't be covered in this chapter, but there are three that are worth some additional explanation in this context.
Description You can associate a textual description with any property and method defined in your class module. This description is then reported in the Object Browser and provides the users of your class with some information about how each member can be used. The description text is visible even when you compile the class into a COM component and another programmer browses its interface from outside the current process.
HelpContextID You can provide a help file that contains a longer description for all the classes, properties, methods, events, controls, and so on exposed by your project. If you do so, you should also specify a distinct ID for each item in the project. When the item is selected in the rightmost pane of the Object Browser, a click on the ? icon automatically takes you to the corresponding help page. The name of the help file can be entered in the Project Properties dialog box.
Hide This Member If you select this option, the property or method in the class module won't be visible in the Object Browser when the class is browsed from outside the project. This setting has no effect within the current project, and it makes sense to use it only in project types other than Standard EXE. Note that "hiding" an item doesn't mean that it's absolutely invisible to other programmers. In fact, even the simple Object Browser that comes with Visual Basic includes a Show Hidden Members menu command (which you can see in Figure 6-6) that lets you discover undocumented features in other libraries (including VB and VBA's own libraries). The decision to hide a given item should be intended just as a suggestion to users of your class, meaning something like, "Don't use this item because it isn't supported and could disappear in future versions of the product."
CAUTION
None of the class attributes—including those described in this section and others that I describe in forthcoming chapters—are stored in source code, so they aren't copied and pasted among different class modules when you copy the code of the procedure they're connected to. Even worse, they aren't even preserved when you use cut-and-paste operations to rearrange the order of methods and properties inside the same class module. If you want to move code in your class modules without also losing all the attributes that are invisibly connected to it, you have to first copy the code where you want to place it and then delete it from its original location. This isn't an issue when you're just renaming a property or a method.
TIP
Oddly, Visual Basic documentation doesn't mention that class modules also support their own Description and HelpContextID attributes and therefore doesn't explain how you can modify them. The trick is simple: Right-click on the class name in the leftmost pane of the Object Browser, and select the Properties command from the pop-up menu.